(function (root, factory) {
  // Browser globals
  root.jichengSearchEngine = factory(root.utils);
}(this, function (utils) {

'use strict';

const {KEYWORD_COLORS} = utils;

const CONFIGS = {
  bookListPages: ['index.html'],
  bookListSeparator: '·',
  openConfirmBookThreshold: 2,
  searchConfirmBookThreshold: 20,
  searchSegmentSeparator: ' » ',
  synonymListFiles: ['../_common/synonyms.json'],
  synonyms: null,
  noPuncRegex: utils.NoPuncHandler.DEFAULT_NOPUNC_REGEX,
  excludeSelectorBase: 'style, script, noscript, form, template, frame, iframe',
};

const URL_PARAMS_SCHEMA_EXPORT = {
  books: {name: '_books', type: 'string'},
  searchUnit: {name: '_unit', type: 'string'},
  keyword: {name: '_keyword', type: 'string'},
  chapter: {name: '_chapter', type: 'string'},
  author: {name: '_author', type: 'string'},
  date: {name: '_date', type: 'string'},
  quality: {name: '_quality', type: 'string'},
  useSynonyms: {name: '_syn', type: 'bool'},
  noPunc: {name: '_noPunc', type: 'bool'},
  case: {name: '_case', type: 'bool'},
  useAncient: {name: '_ancient', type: 'bool'},
  useZhu: {name: '_zhu', type: 'bool'},
  useShu: {name: '_shu', type: 'bool'},
  useJiao: {name: '_jiao', type: 'bool'},
  useDing: {name: '_ding', type: 'bool'},
  excludeSelector: {name: '_exclude', type: 'string'},
  showSummary: {name: '_showSummary', type: 'bool'},
  expandAll: {name: '_expandAll', type: 'bool'},
};

const URL_PARAMS_SCHEMA = Object.assign({
  autoRun: {name: 'autorun', type: 'bool'},
  bookListPages: {name: 'index', type: 'string[]'},
  bookListSeparator: {name: 'bsep', type: 'string'},
  searchSegmentSeparator: {name: 'sep', type: 'string'},
  openConfirmBookThreshold: {name: 'othres', type: 'integer'},
  searchConfirmBookThreshold: {name: 'thres', type: 'integer'},
  synonymListFiles: {name: 'synonym', type: 'string[]'},
  noPuncRegex: {name: 'nopunc', type: 'string'},
}, URL_PARAMS_SCHEMA_EXPORT);

const STORAGE_PREFIX = 'jc_book_search_';

const STORAGE_SCHEMA = {
  synonyms: {type: 'json'},
  noPuncRegex: {type: 'string'},
};

const DOM_CONFIG_KEYS = Object.keys(Object.assign({}, CONFIGS, URL_PARAMS_SCHEMA, STORAGE_SCHEMA));

const DOM_SELECTOR_MAP = key => `#search-panel-${CSS.escape(key)}`;

/** @global */
const jichengSearchEngine = {
  conf: JSON.parse(JSON.stringify(CONFIGS)),

  works: new WeakMap(),

  synonymHandler: null,

  loadOptionsFromPanel(object = this.conf, {
    keys = DOM_CONFIG_KEYS,
    selectorMap = DOM_SELECTOR_MAP,
  } = {}) {
    return utils.jicheng.loadOptionsFromPanel(object, {keys, selectorMap});
  },

  setOptionsToPanel(object = this.conf, {
    keys = DOM_CONFIG_KEYS,
    selectorMap = DOM_SELECTOR_MAP,
  } = {}) {
    utils.jicheng.setOptionsToPanel(object, {keys, selectorMap});
  },

  loadStorage(object = {}, {
    prefix = STORAGE_PREFIX,
    schema = STORAGE_SCHEMA,
    forLocal = false,
    forSession = true,
  } = {}) {
    return utils.jicheng.loadStorage(object, {prefix, schema, forLocal, forSession});
  },

  saveStorage(object = this.conf, {
    prefix = STORAGE_PREFIX,
    schema = STORAGE_SCHEMA,
    forLocal = false,
    forSession = true,
  } = {}) {
    return utils.jicheng.saveStorage(object, {prefix, schema, forLocal, forSession});
  },

  loadUrlParams(object = {}, {
    url,
    schema = URL_PARAMS_SCHEMA,
  } = {}) {
    const params = utils.jicheng.loadUrlParams(object, {url, schema});

    // special validation and handling for several params
    if (typeof params.bookListPages !== 'undefined') {
      params.bookListPages = params.bookListPages.reduce((list, value) => {
        if (!value) {
          return list.concat(CONFIGS.bookListPages);
        }
        list.push(value);
        return list;
      }, []);
    }
    if (typeof params.synonymListFiles !== 'undefined') {
      params.synonymListFiles = params.synonymListFiles.reduce((list, value) => {
        if (!value) {
          return list.concat(CONFIGS.synonymListFiles);
        }
        list.push(value);
        return list;
      }, []);
    }
    if (typeof params.openConfirmBookThreshold !== 'undefined') {
      // discard negative or NaN
      if (!(params.openConfirmBookThreshold >= 0)) {
        delete params.openConfirmBookThreshold;
      }
    }
    if (typeof params.searchConfirmBookThreshold !== 'undefined') {
      // discard negative or NaN
      if (!(params.searchConfirmBookThreshold >= 0)) {
        delete params.searchConfirmBookThreshold;
      }
    }

    return params;
  },

  setUrlParams(object = this.conf, {
    url,
    schema = URL_PARAMS_SCHEMA_EXPORT,
  } = {}) {
    return utils.jicheng.setUrlParams(object, {url, schema});
  },

  updateTitleAndUrl({
    conf = this.conf,
    bookTotal,
    updateUrl = true,
  }) {
    const q = [
        conf.keyword,
        conf.chapter ? `章節:${conf.chapter}` : '',
        conf.author ? `作者:${conf.author}` : '',
        conf.date ? `年份:${conf.date}` : '',
        conf.quality ? `品質:${conf.quality}` : '',
        ].filter(x => !!x).join(' ');
    const n = bookTotal > 0 ? `[${bookTotal}]` : '';
    const title = [
        '典籍檢索' + (q || n ? ':' : ''),
        q,
        n,
        ].filter(x => !!x).join(' ');

    if (updateUrl) {
      const url = this.setUrlParams(conf);
      if (url !== location.href) {
        history.pushState(null, title, url);
      }
    }
    document.title = title;
  },

  mapSelectedBooks(selectElem = document.querySelector('#search-panel-books')) {
    const books = new Map();
    for (const elem of selectElem.querySelectorAll('option')) {
      if (!elem.selected) {
        continue;
      }

      const url = elem.value;
      if (books.has(url)) {
        continue;
      }

      const name = elem.textContent;
      books.set(url, name);
    }
    return books;
  },

  /**
   * Main
   */
  async init() {
    // 初始化 GUI
    const header = document.querySelector('#search-wrapper header');
    header.removeAttribute('class');
    header.textContent = `正在載入檢索引擎...`;

    // 載入 URL 及 storage 參數
    Object.assign(this.conf, this.loadStorage(), this.loadUrlParams());

    // 載入書籍列表
    try {
      const wrapper = document.querySelector('#search-panel-books');

      for (let url of this.conf.bookListPages) {
        let doc;
        try {
          doc = (await utils.xhr({
            url,
            responseType: 'document',
            overrideMimeType: 'text/html;charset=utf8',
          })).response;
        } catch (ex) {
          throw new Error(`network request for '${url}' failed.`);
        }

        if (!doc) {
          throw new Error(`document at '${url}' is null.`);
        }

        const indexname = (this.conf.bookListPages.length > 1) ? (doc.title || url) : '';
        let subWrapper = wrapper;
        for (const elem of doc.querySelectorAll('[data-type="booklist"] a[href]')) {
          const stack = [];

          let desc = null;
          let parent = elem.closest('li');
          if (parent) {
            if (parent.querySelector('ol, ul')) {
              stack.unshift(elem.textContent);
              if (desc === null) {
                desc = elem.getAttribute('aria-description');
              }
            }
            while (true) {
              parent = parent.closest('ol, ul');
              if (!parent) { break; }
              parent = parent.closest('li');
              if (!parent) { break; }
              let p = parent.closest('ol, ul');
              if (!p) { break; }
              p = p.closest('[data-type="booklist"]');
              if (!p) { break; }
              if (p.closest('svg, math')) { break; }

              const a = parent.querySelector('a');
              if (a && a.parentNode === parent) {
                stack.unshift(a.textContent);
                if (desc === null) {
                  desc = a.getAttribute('aria-description');
                }
              }
            }
          }

          if (indexname) {
            stack.unshift(indexname);
          }

          const group = stack.join(this.conf.bookListSeparator);
          if (group) {
            if (group !== subWrapper.label) {
              subWrapper = wrapper.appendChild(document.createElement('optgroup'));
              subWrapper.label = group;
              if (desc) {
                subWrapper.setAttribute('aria-description', desc);
                subWrapper.title = desc;
              }
            }
          } else {
            subWrapper = wrapper;
          }

          const opt = subWrapper.appendChild(document.createElement('option'));
          opt.value = elem.href;
          opt.textContent = elem.textContent || elem.href;
        }
      }

      header.textContent = `本檢索引擎會把選取的典籍即時下載至本機做精確條件比對，可能佔用較多網路流量及本機資源。`;
    } catch (ex) {
      header.className = 'error';
      header.setAttribute('role', 'alert');
      header.setAttribute('aria-live', 'assertive');
      header.textContent = `錯誤：無法載入典籍列表：${ex.message}`;
      return;
    }

    // 載入異體字列表
    try {
      if (this.conf.synonyms) {
        this.synonymHandler = new utils.SynonymHandler(this.conf.synonyms);
      } else {
        this.synonymHandler = await utils.SynonymHandler.fromSources(this.conf.synonymListFiles);
        this.conf.synonyms = this.synonymHandler.data;
      }
    } catch (ex) {
      header.className = 'error';
      header.setAttribute('role', 'alert');
      header.setAttribute('aria-live', 'assertive');
      header.textContent = `錯誤：無法載入異體字列表：${ex.message}`;
      return;
    }

    // update DOM options
    this.setOptionsToPanel();

    // update title
    this.updateTitleAndUrl({
      conf: this.loadOptionsFromPanel(),
      bookTotal: this.mapSelectedBooks().size,
      updateUrl: false,
    });

    const panel = document.querySelector('#search-panel');
    panel.hidden = false;

    // auto run
    if (this.conf.autoRun) {
      this.search({saveState: false}); // async
    }
  },

  filterBooks() {
    let query;
    try {
      query = new utils.ComplexQuery(document.querySelector('#search-panel-booksKeyword').value, {
        useSynonyms: document.querySelector('#search-panel-useSynonyms').checked,
        noPunc: document.querySelector('#search-panel-noPunc').checked,
        ignoreCase: !document.querySelector('#search-panel-case').checked,
        synonymHandler: this.synonymHandler,
        noPuncRegex: this.conf.noPuncRegex,
      });
    } catch (ex) {
      console.error(ex);
      return;
    }
    console.log(`篩選: ${query.query} => ${query.condition}`);

    const booksWrapper = document.querySelector('#search-panel-books');
    for (const elem of booksWrapper.querySelectorAll('option')) {
      elem.hidden = elem.disabled = query.condition && !query.match(elem.textContent);
    }
    for (const elem of booksWrapper.querySelectorAll('optgroup')) {
      elem.hidden = elem.disabled = !elem.querySelector('option:not([hidden])');
    }
  },

  filterBooksByResults() {
    const filter = new Set(
      Array.prototype.map.call(
        document.querySelectorAll('#search-results > details > summary > a:first-child'),
        el => el.textContent
      )
    );

    if (filter.size === 0) {
      console.error(`目前沒有檢索結果，篩選典籍中止。`);
      return;
    }

    const booksWrapper = document.querySelector('#search-panel-books');
    for (const elem of booksWrapper.querySelectorAll('option')) {
      elem.selected = filter.has(elem.textContent);
    }
  },

  filterBooksSelectAll() {
    const booksWrapper = document.querySelector('#search-panel-books');
    for (const elem of booksWrapper.querySelectorAll('option:not([hidden])')) {
      elem.selected = true;
    }
  },

  filterBooksSelectNone() {
    const booksWrapper = document.querySelector('#search-panel-books');
    for (const elem of booksWrapper.querySelectorAll('option:not([hidden])')) {
      elem.selected = false;
    }
  },

  async search({saveState = true} = {}) {
    // 宣告輔助函數
    const onError = (msg) => {
      headerElem.className = 'error';
      headerElem.setAttribute('aria-live', 'assertive');
      headerElem.setAttribute('aria-busy', 'false');
      headerElem.textContent = msg;
    };

    const onStart = ({conf, bookCount, bookTotal, resultCount, searchConfirmBookThreshold}) => {
      // 選取的典籍太多時，向使用者確認
      if (bookTotal >= searchConfirmBookThreshold) {
        if (!confirm(`檢索 ${bookTotal} 部典籍可能佔用大量網路流量及系統資源，確定要繼續嗎？`)) {
          const msg = `已取消檢索大量典籍。`;
          onError(msg);
          return false;
        }
      }

      // 更新標題及 URL
      if (saveState) {
        this.updateTitleAndUrl({
          conf,
          bookTotal,
        });
      }

      // 換成中止按鈕
      document.querySelector('#search-panel-start').hidden = true;
      document.querySelector('#search-panel-cancel').hidden = false;

      // 顯示開始檢索的訊息
      headerElem.textContent = '';
      headerElem.appendChild(document.createTextNode(`正在檢索第`));
      headerElem.appendChild(bookCountElem);
      bookCountElem.textContent = bookCount;
      headerElem.appendChild(document.createTextNode(`/`));
      headerElem.appendChild(bookTotalElem);
      bookTotalElem.textContent = bookTotal;
      headerElem.appendChild(document.createTextNode(`部典籍，已找到`));
      headerElem.appendChild(resultCountElem);
      resultCountElem.textContent = resultCount;
      headerElem.appendChild(document.createTextNode(`筆結果…`));
    };

    const onComplete = ({startTime, bookCount, resultCount, keywordHits, showKeywordHits}) => {
      // 結束檢索並顯示結果
      const time = (Date.now() - startTime) / 1000;
      headerElem.setAttribute('aria-busy', 'false');
      headerElem.textContent = showKeywordHits ?
          `從${bookCount}部典籍中找到${resultCount}筆結果（${keywordHits}處符合的關鍵詞），費時${time}秒。` :
          `從${bookCount}部典籍中找到${resultCount}筆結果，費時${time}秒。`;

      // 顯示「開始檢索」按鈕
      document.querySelector('#search-panel-start').hidden = false;
      document.querySelector('#search-panel-cancel').hidden = true;
    };

    const onNextBook = ({bookCount}) => {
      bookCountElem.textContent = bookCount;
    };

    const onBookError = ({name, url, error}) => {
      const details = wrapperElem.appendChild(document.createElement('details'));
      const summary = details.appendChild(document.createElement('summary'));
      summary.className = 'error';
      summary.textContent = `錯誤：無法載入典籍「${name}」`;
    };

    const onHit = ({
      elem, text, bookName, bookUrl, segmentsInfo, resultCount,
      searchUnit, useAncient, useZhu, useShu, useJiao, useDing, showSummary, expandAll, searchSegmentSeparator,
      qText, qChapter,
    }) => {
      const item = document.createElement('details');
      const header = item.appendChild(document.createElement('summary'));
      header.appendChild(searchHitHeaderGenerator({
        bookName, bookUrl, segmentsInfo,
        searchUnit, useAncient, useZhu, useShu, useJiao, useDing, searchSegmentSeparator,
        qText, qChapter,
      }));

      const div = item.appendChild(document.createElement('div'));
      if (showSummary) {
        const snippet = new utils.SnippetsGenerator(text, qText ? qText.rules[''].patterns : []).run();
        div.textContent = snippet;
        div.className = 'snippets';
      } else {
        div.setAttribute('data-render', 'book');
        div.classList.add('main');

        switch (elem.nodeType) {
          case 1:
            div.innerHTML = elem.innerHTML;
            break;
          case 11:
            div.appendChild(elem);
            break;
          default:
            console.error(new Error(`Unsupported node type: ${elem.nodeName}`));
            break;
        }
      }

      // 標示關鍵詞
      if (qText) {
        utils.jicheng.mark(div, qText.rules[''].patterns, {
          acrossElements: true,
        });
      }

      wrapperElem.appendChild(item);
      resultCountElem.textContent = resultCount;

      utils.jicheng.registerRenderingElem(div, true);
      item.addEventListener('toggle', onToggleResult);
      item.open = expandAll; // can fire the toggle event
    };

    const onSourceHit = ({
      text, snippetsGenerator, bookName, bookUrl, resultCount,
      searchUnit, useAncient, useZhu, useShu, useJiao, useDing, showSummary, expandAll, searchSegmentSeparator,
      qText, qChapter,
    }) => {
      const item = document.createElement('details');
      item.open = expandAll;
      const header = item.appendChild(document.createElement('summary'));
      header.appendChild(searchHitHeaderGenerator({
        bookName, bookUrl,
        searchUnit, useAncient, useZhu, useShu, useJiao, useDing, searchSegmentSeparator,
        qText, qChapter,
      }));

      const div = item.appendChild(document.createElement('div'));
      const pre = div.appendChild(document.createElement('pre'));
      pre.className = 'source';

      let markedHtml;
      if (snippetsGenerator) {
        markedHtml = snippetsGenerator.markHits((text, keyword, keywordIndex) => {
          const escapedText = utils.escapeHtml(text, {noDoubleQuotes: true});
          if (!keyword) { return escapedText; }
          const index = keywordIndex % KEYWORD_COLORS;
          return `<mark data-markjs="true" tabindex="0" class="kw${index}">${escapedText}</mark>`;
        });
      }

      if (showSummary) {
        div.className = 'snippets';
        if (snippetsGenerator) {
          // 建立比對已標記關鍵詞的 regex
          // 若關鍵詞跨越多行，只顯示第一行
          // 若行首或行尾有不成對的 tagEnd 或 tagStart 則補上
          const tagStart = '<mark\\b[^<>]*>';
          const tagEnd = '</mark>';
          const regex1 = new RegExp(tagStart);
          const regex2 = new RegExp(`^(?:(?!${tagStart}).)*?${tagEnd}`);
          const regex3 = new RegExp(`(${tagStart})(?:(?!${tagEnd}).)*?$`);

          let lastTagStart;
          markedHtml = markedHtml.split('\n').reduce((lines, line, index) => {
            if (!regex1.test(line)) {
              return lines;
            }
            line = line.replace(regex2, (m) => {
              return lastTagStart + m;
            });
            line = line.replace(regex3, (m, tagStart) => {
              lastTagStart = tagStart;
              return m + tagEnd;
            });
            lines.push(`<b>行${index + 1}: </b>${line}`);
            return lines;
          }, []).join('\n');
          pre.innerHTML = markedHtml;
        }
      } else {
        if (snippetsGenerator) {
          pre.innerHTML = markedHtml;
        } else {
          pre.textContent = text;
        }
      }

      wrapperElem.appendChild(item);
      resultCountElem.textContent = resultCount;
    };

    const searchHitHeaderGenerator = ({
      bookName, bookUrl, segmentsInfo,
      searchUnit, useAncient, useZhu, useShu, useJiao, useDing,
      searchSegmentSeparator: separator,
      qText, qChapter,
    }) => {
      const params = new URLSearchParams();
      if (searchUnit !== 'source') {
        params.append('useAncient', useAncient);
        params.append('useZhu', useZhu ? 'diff' : 'reject');
        params.append('useShu', useShu ? 'diff' : 'reject');
        params.append('useJiao', useJiao ? 'diff' : 'reject');
        params.append('useDing', useDing ? 'accept' : 'reject');
        if (qText) {
          for (const pattern of qText.rules[''].patterns) {
            params.append('hl', pattern.toString());
          }
        }
      }

      const urlObj = new URL(bookUrl);
      const query = params.toString();
      urlObj.hash = query ? '?' + query : '';

      const breadcrumbs = document.createDocumentFragment();
      const anchor = breadcrumbs.appendChild(document.createElement('a'));
      anchor.href = urlObj.href;
      anchor.target = '_blank';
      anchor.textContent = bookName;

      let segments = [];
      if (segmentsInfo) {
        const {
          sectionStack,
          sectionIndex,
          sectionIsParagraph,
          mapRefXpath,
        } = segmentsInfo;

        const lastIndex = sectionStack.length - 1;

        // Array.prototype.map(fn) 會略過空 slot
        // e.g. [1, , 3] => [fn(1), , fn(3)]
        segments = sectionStack.map((elem, index) => {
          let label;
          let isParagraph = false;
          if (sectionIsParagraph && index === lastIndex) {
            label = '#' + (sectionIndex + 1);
            isParagraph = true;
          } else {
            const elemClone = elem.cloneNode(true);
            for (const elem of elemClone.querySelectorAll('[data-sec=""]')) {
              elem.remove();
            }
            label = elemClone.textContent.trim();
          }
          const xpath = mapRefXpath.get(elem);
          return {label, xpath, isParagraph};
        });
      }

      let started = false;
      for (const segment of segments) {
        if (!segment) {
          if (started) {
            breadcrumbs.appendChild(document.createTextNode(separator));
          }
          continue;
        }

        started = true;
        const {label, xpath, isParagraph} = segment;
        params.set('t', xpath);
        const query = params.toString();
        urlObj.hash = query ? '?' + query : '';

        breadcrumbs.appendChild(document.createTextNode(separator));
        const anchor = breadcrumbs.appendChild(document.createElement('a'));
        anchor.href = urlObj.href;
        anchor.target = '_blank';
        anchor.textContent = label;

        if (qChapter && !isParagraph) {
          utils.jicheng.mark(anchor, qChapter.rules[''].patterns);
        }
      }

      return breadcrumbs;
    };

    const onToggleResult = (event) => {
      const details = event.target;
      if (!details.open) { return; }

      const {useAncient, useZhu, useShu, useJiao, useDing} = work;

      const div = details.querySelector('div');
      utils.jicheng.showRenderingElem(div, {
        useAncient,
        useZhu: useZhu ? 'diff' : 'reject',
        useShu: useShu ? 'diff' : 'reject',
        useJiao: useJiao ? 'diff' : 'reject',
        useDing: useDing ? 'accept' : 'reject',
      });
    };

    // 將 #search-results 換成新元素，並中止原非同步流程
    const wrapperElemOrig = document.querySelector('#search-results');
    const workOrig = this.works.get(wrapperElemOrig);
    if (workOrig) { workOrig.finalize(); }

    const wrapperElem = wrapperElemOrig.cloneNode(false);
    wrapperElemOrig.replaceWith(wrapperElem);

    // 建立檢索結果主標及相關元素
    const headerElem = wrapperElem.appendChild(document.createElement('header'));
    headerElem.setAttribute('role', 'alert');
    headerElem.setAttribute('aria-live', 'polite');
    headerElem.setAttribute('aria-atomic', 'true');
    headerElem.setAttribute('aria-busy', 'true');
    headerElem.textContent = `檢索準備中...`;

    const bookCountElem = document.createElement('span');
    const bookTotalElem = document.createElement('span');
    const resultCountElem = document.createElement('span');

    // 建立新的非同步工作並執行
    const work = (() => {
      try {
        return new SearchWork({
          conf: this.loadOptionsFromPanel(),
          books: this.mapSelectedBooks(),
          onStart,
          onComplete,
          onNextBook,
          onBookError,
          onHit,
          onSourceHit,
        });
      } catch (ex) {
        onError(ex.message);
        throw ex;
      }
    })();
    this.works.set(wrapperElem, work);
    await work.start();
  },

  viewBooks() {
    const books = this.mapSelectedBooks();

    // 選取的典籍太多時，向使用者確認
    if (books.size >= this.conf.openConfirmBookThreshold) {
      if (!confirm(`確定要開啟 ${books.size} 部典籍檢視？`)) {
        return;
      }
    }

    for (const [url, name] of books) {
      window.open(url);
    }
  },

  cancel() {
    const wrapperElem = document.querySelector('#search-results');
    const work = this.works.get(wrapperElem);
    if (!work) { return; }
    work.finalize();
  },
};

class SearchWork {
  constructor({
    conf,
    books,
    onStart,
    onComplete,
    onNextBook,
    onBookError,
    onHit,
    onSourceHit,
    engine = jichengSearchEngine,
  } = {}) {
    this.engine = engine;
    this.onStart = onStart;
    this.onComplete = onComplete;
    this.onNextBook = onNextBook;
    this.onBookError = onBookError;
    this.onHit = onHit;
    this.onSourceHit = onSourceHit;

    this.conf = JSON.parse(JSON.stringify(conf));
    this.books = books;
    this.searchUnit = conf.searchUnit;
    this.useAncient = conf.useAncient;
    this.useZhu = conf.useZhu;
    this.useShu = conf.useShu;
    this.useJiao = conf.useJiao;
    this.useDing = conf.useDing;
    this.showSummary = conf.showSummary;
    this.expandAll = conf.expandAll;
    this.searchConfirmBookThreshold = conf.searchConfirmBookThreshold;
    this.searchSegmentSeparator = conf.searchSegmentSeparator;

    // 若無書則中止
    if (books.size <= 0) {
      throw new Error(`錯誤：請選取要檢索的典籍。`);
    }

    // 建立比對及標示關鍵詞用的 regex
    // 若發生錯誤則中止
    let qText;
    {
      const query = conf.keyword;
      if (query.trim()) {
        try {
          qText = new utils.ComplexQuery(query, {
            useSynonyms: conf.useSynonyms,
            noPunc: conf.noPunc,
            ignoreCase: !conf.case,
            synonymHandler: jichengSearchEngine.synonymHandler,
            noPuncRegex: conf.noPuncRegex,
          });
        } catch (ex) {
          throw new Error(`錯誤：${ex.message}`);
        }
        console.log(`檢索: ${qText.query} => ${qText.condition}`);
      }
    }

    let qChapter;
    {
      const query = conf.chapter;
      if (query.trim()) {
        try {
          qChapter = new utils.ComplexQuery(query, {
            useSynonyms: conf.useSynonyms,
            noPunc: conf.noPunc,
            ignoreCase: !conf.case,
            synonymHandler: jichengSearchEngine.synonymHandler,
            noPuncRegex: conf.noPuncRegex,
          });
        } catch (ex) {
          throw new Error(`錯誤：${ex.message}`);
        }
        console.log(`檢索 (章節): ${qChapter.query} => ${qChapter.condition}`);
      }
    }

    let qAuthor;
    {
      const query = conf.author;
      if (query.trim()) {
        try {
          qAuthor = new utils.ComplexQuery(query, {
            useSynonyms: conf.useSynonyms,
            noPunc: conf.noPunc,
            ignoreCase: !conf.case,
            synonymHandler: jichengSearchEngine.synonymHandler,
            noPuncRegex: conf.noPuncRegex,
          });
        } catch (ex) {
          throw new Error(`錯誤：${ex.message}`);
        }
        console.log(`檢索 (作者): ${qAuthor.query} => ${qAuthor.condition}`);
      }
    }

    let qDate;
    {
      const query = conf.date;
      if (query.trim()) {
        try {
          qDate = new utils.DateQuery(query);
        } catch (ex) {
          throw new Error(`錯誤：${ex.message}`);
        }
        console.log(`檢索 (年份): ${qDate.query} => ${qDate.min} ~ ${qDate.max}`);
      }
    }

    let qQuality;
    {
      const query = conf.quality;
      if (query.trim()) {
        try {
          qQuality = new utils.QualityQuery(query);
        } catch (ex) {
          throw new Error(`錯誤：${ex.message}`);
        }
        console.log(`檢索 (品質): ${qQuality.query} => ${qQuality.min} ~ ${qQuality.max}`);
      }
    }

    // 檢查 excludeSelector 語法
    // 若發生錯誤則中止
    try {
      conf.excludeSelector && document.querySelector(conf.excludeSelector);
    } catch (ex) {
      throw new Error(`錯誤：排除元素「${conf.excludeSelector}」語法有誤。`);
    }

    // 建立略過元素的 selector
    let removeElemSelector = [conf.excludeSelectorBase];
    let removeWrapperSelector = [];
    {
      if (!conf.useAncient) {
        removeElemSelector.push('[data-rev="古版"]');
        removeWrapperSelector.push('[data-rev="古版-元素"]');
      } else {
        removeElemSelector.push('[data-rev="今版"]');
        removeWrapperSelector.push('[data-rev="今版-元素"]');
      }

      if (!conf.useZhu) {
        removeElemSelector.push('span[data-rev="注"]');
      }

      if (!conf.useShu) {
        removeElemSelector.push('span[data-rev="疏"]');
      }

      if (!conf.useJiao) {
        removeElemSelector.push('span[data-rev="校"]');
      }

      if (!conf.useDing) {
        removeElemSelector.push('span[data-rev="訂"]:not([data-rev="*"])');
      } else {
        removeElemSelector.push('span[data-rev="訂"][data-ver]');
      }

      if (conf.excludeSelector) {
        removeElemSelector.push(conf.excludeSelector);
      }

      removeElemSelector = removeElemSelector.join(', ');
      removeWrapperSelector = removeWrapperSelector.join(', ');
    }

    this.qText = qText;
    this.qChapter = qChapter;
    this.qAuthor = qAuthor;
    this.qDate = qDate;
    this.qQuality = qQuality;
    this.removeElemSelector = removeElemSelector;
    this.removeWrapperSelector = removeWrapperSelector;
    this.showKeywordHits = this.searchUnit === 'source' && qText;
  }

  async start() {
    this.startTime = Date.now();
    this.cancled = false;
    this.bookCount = 0;
    this.bookTotal = this.books.size;
    this.resultCount = 0;
    this.keywordHits = 0;

    const checkStart = this.onStart && this.onStart({
      conf: this.conf,
      bookCount: this.bookCount,
      bookTotal: this.bookTotal,
      resultCount: this.resultCount,
      searchConfirmBookThreshold: this.searchConfirmBookThreshold,
    });
    if (checkStart === false) { return; }

    // 依次載入各頁面做檢索
    for (const [url, name] of this.books) {
      try {
        // 檢查檢索是否已中止
        if (this.cancled) { break; }

        this.bookCount++;
        this.onNextBook && this.onNextBook({bookCount: this.bookCount});

        let doc;
        try {
          doc = (await utils.xhr({
            url,
            responseType: 'document',
            overrideMimeType: 'text/html',
          })).response;
          if (!doc) {
            throw new Error(`document for '${name}' is null.`);
          }
        } catch (ex) {
          this.onBookError && this.onBookError({name, url, error: ex});
          throw new Error(`unable to retrieve document for '${name}' (${url}): ${ex.message}`);
        }

        await this.searchBookDoc({
          doc,
          bookName: name,
          bookUrl: url,
        });
      } catch (ex) {
        console.error(ex);
      }
    }

    // 檢索完成
    this.finalize();
  }

  async searchBookDoc({
    doc,
    bookName, bookUrl,
  }) {
    // 根元素
    const mainElem = doc.querySelector('main');

    // 篩選元資料
    const metaElem = mainElem.querySelector('header > dl.元資料');
    const meta = metaElem ? utils.loadDlKeyValue(metaElem) : null;

    if (this.qAuthor) {
      if (!meta || !meta['作者'] || !meta['作者'].some(author => this.qAuthor.match(author))) { return; }
    }

    if (this.qDate) {
      if (!meta || !meta['年份'] || !meta['年份'].some(date => this.qDate.match(date))) { return; }
    }

    if (this.qQuality) {
        const q = meta && meta['品質'] || ['-1%'];
        if (!q.some(quality => this.qQuality.match(quality))) { return; }
    }

    // 為參考節點建立對照資訊
    let refSelector = '[data-sec="h1"], [data-sec="h2"], [data-sec="h3"], [data-sec="h4"], [data-sec="h5"], [data-sec="h6"]';
    switch (this.searchUnit) {
      case 'paragraph':
        refSelector += ', [data-sec="p"]';
        break;
    }

    // 記錄各節點在原始 HTML 中的 XPath
    const mapRefXpath = new WeakMap();
    if (['chapter', 'paragraph'].includes(this.searchUnit)) {
      const mapChildInfo = new WeakMap();
      mapChildInfo.set(mainElem, new Map());
      mapRefXpath.set(mainElem, '');

      const nodes = function* () {
        const nodeIterator = doc.createNodeIterator(mainElem, NodeFilter.SHOW_ELEMENT);
        nodeIterator.nextNode(); // 略過 mainElem
        let node;
        while (node = nodeIterator.nextNode()) {
          yield node;
        }
      };
      await utils.forEachAsync(nodes(), (elem) => {
        const curNodeName = elem.nodeName;
        const parentElem = elem.parentNode;
        const childInfo = mapChildInfo.get(parentElem);

        // update sibling element conut for the parent
        const pos = (childInfo.get(curNodeName) || 0) + 1;
        childInfo.set(curNodeName, pos);

        // get XPath
        const parentXpath = mapRefXpath.get(parentElem);
        const xpath = parentXpath ? `${parentXpath}/${curNodeName}[${pos}]` : `${curNodeName}[${pos}]`;

        mapChildInfo.set(elem, new Map());
        mapRefXpath.set(elem, xpath);
      });
    }

    // 更新佔位元素使之能在檢索結果正確顯示
    if (this.searchUnit !== 'source') {
      utils.jicheng.toggleHiddenContent(mainElem, '[data-jc-innerhtml]');
      utils.jicheng.toggleUnwrappedElems(mainElem, 'jc-t', null, (oldElem, newElem) => {
        mapRefXpath.set(newElem, mapRefXpath.get(oldElem));
      });
    }

    // 移除不檢索的元素
    if (this.searchUnit !== 'source') {
      await utils.forEachAsync(
        mainElem.querySelectorAll(this.removeElemSelector),
        (elem) => {
          elem.remove();
        },
      );

      await utils.forEachAsync(
        mainElem.querySelectorAll(this.removeWrapperSelector),
        (elem) => {
          let child;
          while (child = elem.firstChild) {
            elem.before(child);
          }
          elem.remove();
        },
      );
    }

    // 針對指定的搜尋單位做比對，並輸出結果
    switch (this.searchUnit) {
      case 'book': {
        this.searchHitHandler({
          elem: mainElem,
          bookName, bookUrl,
        });
        break;
      }

      case 'chapter':
      case 'paragraph': {
        const mapOrigClone = new WeakMap();
        const sectionStack = [];
        let sectionFragment = document.createDocumentFragment();
        let sectionIndex = 0;
        let sectionIsParagraph = false;

        const searchFragment = () => {
          // 比對章節關鍵詞
          if (this.qChapter) {
            if (!sectionStack.some((elem, index) => {
              if (sectionIsParagraph && index === sectionStack.length - 1) {
                return false;
              }
              return this.qChapter.match(elem.textContent.trim());
            })) {
              return;
            }
          }

          this.searchHitHandler({
            elem: sectionFragment,
            bookName, bookUrl,
            segmentsInfo: {
              sectionStack,
              sectionIndex,
              sectionIsParagraph,
              mapRefXpath,
            },
          });
        };

        const nodesGenerator = function* () {
          const nodeIterator = document.createNodeIterator(mainElem, NodeFilter.SHOW_ALL);
          nodeIterator.nextNode(); // 略過 mainElem
          let node;
          while (node = nodeIterator.nextNode()) {
            yield node;
          }
        };

        await utils.forEachAsync(nodesGenerator(), (node) => {
          // handle sectioning element
          if (node.nodeType === 1 && node.matches(refSelector)
              && !(node.matches('[data-sec="p"]') && node.parentNode.closest(refSelector))) {
            // 搜尋目前的 sectionFragment
            searchFragment();

            // 建立新 sectionFragment
            sectionFragment = document.createDocumentFragment();
            sectionIndex++;

            // 更新 sectionStack
            if (sectionIsParagraph) {
              sectionStack.pop();
            }
            sectionIsParagraph = !/^h(\d+)$/i.test(node.getAttribute('data-sec'));
            if (!sectionIsParagraph) {
              const level = parseInt(RegExp.$1, 10);
              sectionStack[level - 1] = node;
              sectionStack.length = level;
            } else {
              sectionStack.push(node);
            }

            // 為 node 及其祖節點建立複本並加入目前的 sectionFragment
            mapOrigClone.set(node, node.cloneNode(false));
            let current = node;
            let parent = current.parentNode;
            while (parent && parent !== mainElem) {
              const parentClone = parent.cloneNode(false);
              parentClone.appendChild(mapOrigClone.get(current));
              mapOrigClone.set(parent, parentClone);
              current = parent;
              parent = current.parentNode;
            }
            sectionFragment.appendChild(mapOrigClone.get(current));

            return;
          }

          // 建立複本並加入其 parent 的複本（或目前的 sectionFragment）
          mapOrigClone.set(node, node.cloneNode(false));
          const parent = node.parentNode;
          if (parent === mainElem) {
            sectionFragment.appendChild(mapOrigClone.get(node));
          } else {
            mapOrigClone.get(parent).appendChild(mapOrigClone.get(node));
          }
        });

        // 搜尋最後一個 sectionFragment
        searchFragment();

        break;
      }

      case 'source': {
        this.searchSourceHitHandler({
          elem: mainElem,
          bookName, bookUrl,
        });
        break;
      }
    }
  }

  searchHitHandler({
    elem,
    bookName, bookUrl,
    segmentsInfo,
  }) {
    // 檢查檢索是否已中止
    if (this.cancled) { return; }

    let text = elem.textContent;

    if (this.qText) {
      if (!this.qText.match(text)) { return; }
    }

    this.resultCount++;
    this.onHit && this.onHit({
      elem, text, bookName, bookUrl, segmentsInfo,
      resultCount: this.resultCount,
      searchUnit: this.searchUnit,
      useAncient: this.useAncient,
      useZhu: this.useZhu,
      useShu: this.useShu,
      useJiao: this.useJiao,
      useDing: this.useDing,
      showSummary: this.showSummary,
      expandAll: this.expandAll,
      searchSegmentSeparator: this.searchSegmentSeparator,
      qText: this.qText,
      qChapter: this.qChapter,
    });
  }

  searchSourceHitHandler({
    elem,
    bookName, bookUrl,
  }) {
    // 檢查檢索是否已中止
    if (this.cancled) { return; }

    let text = elem.innerHTML.replace(/^\n/, '');

    let snippetsGenerator;
    if (this.qText) {
      if (!this.qText.match(text)) { return; }

      snippetsGenerator = new utils.SnippetsGenerator(
        text,
        this.qText ? this.qText.rules[''].patterns : [],
        {detailedMap: true},
      );
      this.keywordHits += [...snippetsGenerator.hitsMap.values()].reduce((rv, hits) => rv + hits.length, 0);
    }

    this.resultCount++;
    this.onSourceHit && this.onSourceHit({
      text, snippetsGenerator, bookName, bookUrl,
      resultCount: this.resultCount,
      searchUnit: this.searchUnit,
      useAncient: this.useAncient,
      useZhu: this.useZhu,
      useShu: this.useShu,
      useJiao: this.useJiao,
      useDing: this.useDing,
      showSummary: this.showSummary,
      expandAll: this.expandAll,
      searchSegmentSeparator: this.searchSegmentSeparator,
      qText: this.qText,
      qChapter: this.qChapter,
    });
  }

  finalize() {
    // 檢查檢索是否已中止
    if (this.cancled) { return; }

    this.onComplete && this.onComplete({
      startTime: this.startTime,
      bookCount: this.bookCount,
      resultCount: this.resultCount,
      keywordHits: this.keywordHits,
      showKeywordHits: this.showKeywordHits,
    });
    this.cancled = true;
  }
}

document.querySelector('#search-panel-booksSelectAll').addEventListener('click', (event) => {
  jichengSearchEngine.filterBooksSelectAll();
});

document.querySelector('#search-panel-booksSelectNone').addEventListener('click', (event) => {
  jichengSearchEngine.filterBooksSelectNone();
});

document.querySelector('#search-panel-form-range').addEventListener('submit', (event) => {
  event.preventDefault();
  jichengSearchEngine.filterBooks();
});

document.querySelector('#search-panel-synonymsList').addEventListener('click', (event) => {
  event.preventDefault();
  const title = `請設定新的異體字表 (JSON)：`;
  const key = 'synonyms';

  let value = prompt(title, JSON.stringify(jichengSearchEngine.conf[key]));
  let handler;
  while (true) {
    if (value === null) {
      return;  // cancel
    }
    try {
      const data = JSON.parse(value);

      handler = new utils.SynonymHandler(data);

      break;
    } catch (ex) {
      alert(`輸入格式錯誤：${ex.message}`);
      value = prompt(title, value);
    }
  }

  jichengSearchEngine.synonymHandler = handler;
  jichengSearchEngine.conf[key] = handler.data;
  jichengSearchEngine.saveStorage(jichengSearchEngine.conf, {
    schema: {[key]: STORAGE_SCHEMA[key]},
  });
});

document.querySelector('#search-panel-noPuncList').addEventListener('click', (event) => {
  event.preventDefault();
  const title = `請設定要略過的標點符號字元（正規表示式）：`;
  const key = 'noPuncRegex';

  let value = prompt(title, jichengSearchEngine.conf[key]);
  while (true) {
    if (value === null) {
      return;  // cancel
    }
    try {
      new utils.NoPuncHandler(value);
      break;
    } catch (ex) {
      alert(`正規表示式語法錯誤：${ex.message}`);
      value = prompt(title, value);
    }
  }

  jichengSearchEngine.conf[key] = value;
  jichengSearchEngine.saveStorage(jichengSearchEngine.conf, {
    schema: {[key]: STORAGE_SCHEMA[key]},
  });
});

document.querySelector('#search-panel-form-settings').addEventListener('submit', (event) => {
  event.preventDefault();
  jichengSearchEngine.search();
});

document.querySelector('#search-panel-cancel').addEventListener('click', (event) => {
  jichengSearchEngine.cancel();
});

document.querySelector('#search-panel-filterBookByResults').addEventListener('click', (event) => {
  jichengSearchEngine.filterBooksByResults();
});

document.querySelector('#search-panel-viewBooks').addEventListener('click', (event) => {
  jichengSearchEngine.viewBooks();
});

document.addEventListener('DOMContentLoaded', (event) => {
  jichengSearchEngine.init();
});

window.addEventListener('popstate', (event) => {
  const conf = jichengSearchEngine.loadUrlParams();

  // force reload for a change of required external resources
  const confCheck = Object.assign({}, CONFIGS, conf);
  for (const key of ['bookListPages', 'bookListSeparator', 'synonymListFiles']) {  
    if (JSON.stringify(confCheck[key]) !== JSON.stringify(jichengSearchEngine.conf[key])) {
      location.reload();
      return;
    }
  }

  // update options
  Object.assign(jichengSearchEngine.conf, conf);
  jichengSearchEngine.setOptionsToPanel();

  // update title
  jichengSearchEngine.updateTitleAndUrl({
    conf: jichengSearchEngine.loadOptionsFromPanel(),
    bookTotal: jichengSearchEngine.mapSelectedBooks().size,
    updateUrl: false,
  });

  // auto run
  if (conf.autoRun) {
    jichengSearchEngine.search({saveState: false}); // async
  }
});

return jichengSearchEngine;

}));
